JavaScript 作用域、闭包详解
JavaScript
04/14/2021
前言
JS 属于静态词法作用域,正是由于这个原因,导致 JS 中的变量和 this
指向变得很奇怪,所以本文就该问题进行了详细展开,让我们彻底把这个问题弄懂,因为……这也是面试高频考点之一 XD
全文将围绕以下内容进行展开:
当 JavaScript 代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。
对于每个执行上下文,都有三个重要属性:
- 变量对象(Variable object,VO)
- 作用域链(Scope chain)
- this
以上三部分也是理解 JS 作用域和闭包的核心知识点,一旦掌握,你就打通了任督二脉
P.S. 文章基本摘录自冴羽大佬的博客,因为大佬叙述的整体框架太棒了,逻辑梳理得很完美,对于理解 JS 作用域闭包很有帮助,文笔也很赞,本文主要用于个人整理学习使用
变量对象
变量对象
变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。
因为不同执行上下文下的变量对象稍有不同,所以我们来聊聊全局上下文下的变量对象和函数上下文下的变量对象。
全局上下文
全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。
在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。
例如,当 JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。
如果看的不是很懂的话,容我再来介绍下全局对象:
1.可以通过 this 引用,在客户端 JavaScript 中,全局对象就是 Window 对象。
console.log(this)
2.全局对象是由 Object 构造函数实例化的一个对象。
console.log(this instanceof Object)
3.预定义了一堆,嗯,一大堆函数和属性。
// 都能生效console.log(Math.random())console.log(this.Math.random())
4.作为全局变量的宿主。
var a = 1console.log(this.a)
5.客户端 JavaScript 中,全局对象有 window 属性指向自身。
var a = 1console.log(window.a)
this.window.b = 2console.log(this.b)
花了一个大篇幅介绍全局对象,其实就想说:
全局上下文中的变量对象就是全局对象呐!
函数上下文
在函数上下文中,我们用活动对象(activation object, AO
)来表示变量对象。
活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object 呐,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。
活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments
属性初始化。arguments
属性值是 Arguments
对象。
执行过程
执行上下文的代码会分成两个阶段进行处理:分析和执行,我们也可以叫做:
- 进入执行上下文
- 代码执行
进入执行上下文
当进入执行上下文时,这时候还没有执行代码,
变量对象会包括:
函数的所有形参 (如果是函数上下文)
- 由名称和对应值组成的一个变量对象的属性被创建
- 没有实参,属性值设为 undefined
函数声明
- 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
- 如果变量对象已经存在相同名称的属性,则完全替换这个属性
变量声明
- 由名称和对应值(undefined)组成一个变量对象的属性被创建;
- 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性
举个例子:
function foo(a) { var b = 2 function c() {} var d = function () {}
b = 3}
foo(1)
在进入执行上下文后,这时候的 AO
是:
AO = { arguments: { 0: 1, length: 1 }, a: 1, b: undefined, c: reference to function c(){}, d: undefined}
代码执行
在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值
还是上面的例子,当代码执行完后,这时候的 AO 是:
AO = { arguments: { 0: 1, length: 1 }, a: 1, b: 3, c: reference to function c(){}, d: reference to FunctionExpression "d"}
到这里变量对象的创建过程就介绍完了,让我们简洁的总结我们上述所说:
全局上下文的变量对象初始化是全局对象
函数上下文的变量对象初始化只包括 Arguments 对象
在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
在代码执行阶段,会再次修改变量对象的属性值
思考题
最后让我们看几个例子:
- 第一题
function foo() { console.log(a) a = 1}
foo() // ???
function bar() { a = 1 console.log(a)}bar() // ???
第一段会报错:Uncaught ReferenceError: a is not defined
。
第二段会打印:1
。
这是因为函数中的 "a" 并没有通过 var 关键字声明,所有不会被存放在 AO 中。
第一段执行 console 的时候, AO 的值是:
AO = { arguments: { length: 0, },}
没有 a 的值,然后就会到全局去找,全局也没有,所以会报错。
当第二段执行 console 的时候,全局对象已经被赋予了 a 属性,这时候就可以从全局找到 a 的值,所以会打印 1。
- 第二题
console.log(foo)
function foo() { console.log("foo")}
var foo = 1
会打印函数,而不是 undefined
。
这是因为在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。
作用域链
在上文我们讲到,当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
下面,让我们以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的。
函数创建
在 JavaScript
中,函数的作用域在函数定义的时候就决定了。
这是因为函数有一个内部属性 [[scope]]
,当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]]
就是所有父变量对象的层级链,但是注意:[[scope]]
并不代表完整的作用域链!
举个例子:
function foo() { function bar() { ... }}
函数创建时,各自的[[scope]]
为:
foo.[[scope]] = [ globalContext.VO];
bar.[[scope]] = [ fooContext.AO, globalContext.VO];```javascript
### 函数激活
当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。
这时候执行上下文的作用域链,我们命名为 Scope:
```javascriptScope = [AO].concat([[Scope]]);
至此,作用域链创建完毕。
捋一捋
以下面的例子为例,结合着之前讲的变量对象和执行上下文栈,我们来总结一下函数执行上下文中作用域链和变量对象的创建过程:
var scope = "global scope"function checkscope() { var scope2 = "local scope" return scope2}checkscope()
执行过程如下:
- checkscope 函数被创建,保存作用域链到 内部属性
[[scope]]
checkscope[[scope]] = [globalContext.VO]
- 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
ECStack = [checkscopeContext, globalContext]
- checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数
[[scope]]
属性创建作用域链
checkscopeContext = { Scope: checkscope[[scope]],}
- 第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
checkscopeContext = { AO: { arguments: { length: 0 }, scope2: undefined }, Scope: checkscope.[[scope]],}
- 第三步:将活动对象压入 checkscope 作用域链顶端
checkscopeContext = { AO: { arguments: { length: 0, }, scope2: undefined, }, Scope: [AO, [[Scope]]],}
- 准备工作做完,开始执行函数,随着函数的执行,修改
AO
的属性值
checkscopeContext = { AO: { arguments: { length: 0, }, scope2: "local scope", }, Scope: [AO, [[Scope]]],}
7.查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
ECStack = [globalContext]
this
下面重点讲讲 this,然而不好讲。
……
因为我们要从 ECMASciript5 规范开始讲起。
先奉上 ECMAScript 5.1 规范地址:
英文版:http://es5.github.io/#x15.1
中文版:http://yanhaijing.com/es5/#115
让我们开始了解规范吧!
Types
首先是第 8 章 Types:
Types are further subclassified into ECMAScript language types and specification types.
An ECMAScript language type corresponds to values that are directly manipulated by an ECMAScript programmer using the ECMAScript language. The ECMAScript language types are Undefined, Null, Boolean, String, Number, and Object.
A specification type corresponds to meta-values that are used within algorithms to describe the semantics of ECMAScript language constructs and ECMAScript language types. The specification types are Reference, List, Completion, Property Descriptor, Property Identifier, Lexical Environment, and Environment Record.
我们简单的翻译一下:
ECMAScript 的类型分为语言类型和规范类型。
ECMAScript 语言类型是开发者直接使用 ECMAScript 可以操作的。其实就是我们常说的 Undefined
, Null
, Boolean
, String
, Number
, 和 Object
。
而规范类型相当于 meta-values
,是用来用算法描述 ECMAScript 语言结构和 ECMAScript 语言类型的。规范类型包括:Reference
, List
, Completion
, Property Descriptor
, Property Identifier
, Lexical Environment
, 和 Environment Record
。
没懂?没关系,我们只要知道在 ECMAScript 规范中还有一种只存在于规范中的类型,它们的作用是用来描述语言底层行为逻辑。
今天我们要讲的重点是便是其中的 Reference
类型。它与 this
的指向有着密切的关联。
Reference
那什么又是 Reference ?
让我们看 8.7 章 The Reference Specification Type:
The Reference type is used to explain the behaviour of such operators as delete, typeof, and the assignment operators.
所以 Reference 类型就是用来解释诸如 delete、typeof 以及赋值等操作行为的。
抄袭尤雨溪大大的话,就是:
这里的 Reference
是一个 Specification Type
,也就是 “只存在于规范里的抽象类型”。它们是为了更好地描述语言的底层行为逻辑才存在的,但并不存在于实际的 js 代码中。
再看接下来的这段具体介绍 Reference
的内容:
A Reference is a resolved name binding.
A Reference consists of three components, the base value, the referenced name and the Boolean valued strict reference flag.
The base value is either undefined, an Object, a Boolean, a String, a Number, or an environment record (10.2.1).
A base value of undefined indicates that the reference could not be resolved to a binding. The referenced name is a String.
这段讲述了 Reference
的构成,由三个组成部分,分别是:
base value
referenced name
strict reference
可是这些到底是什么呢?
我们简单的理解的话:
base value
就是属性所在的对象或者就是 EnvironmentRecord
,它的值只可能是 undefined
, an Object
, a Boolean
, a String
, a Number
, or an environment record
其中的一种。
referenced name
就是属性的名称。
举个例子:
var foo = 1
// 对应的Reference是:var fooReference = { base: EnvironmentRecord, name: "foo", strict: false,}
再举个例子:
var foo = { bar: function () { return this },}
foo.bar() // foo
// bar对应的Reference是:var BarReference = { base: foo, propertyName: "bar", strict: false,}
而且规范中还提供了获取 Reference
组成部分的方法,比如 GetBase
和 IsPropertyReference
。
这两个方法很简单,简单看一看:
GetBase
GetBase(V). Returns the base value component of the reference V.
返回 reference
的 base value
。
IsPropertyReference
IsPropertyReference(V). Returns true if either the base value is an object or HasPrimitiveBase(V) is true; otherwise returns false.
简单的理解:如果 base value
是一个对象,就返回 true
。
GetValue
除此之外,紧接着在 8.7.1 章规范中就讲了一个用于从 Reference
类型获取对应值的方法: GetValue
。
简单模拟 GetValue
的使用:
var foo = 1;
var fooReference = { base: EnvironmentRecord, name: 'foo', strict: false};
GetValue(fooReference) // 1;GetValue 返回对象属性真正的值,但是要注意:
调用 GetValue
,返回的将是具体的值,而不再是一个 Reference
这个很重要,这个很重要,这个很重要。
确定 this 的值
关于 Reference 讲了那么多,为什么要讲 Reference 呢?到底 Reference 跟本文的主题 this 有哪些关联呢?如果你能耐心看完之前的内容,以下开始进入高能阶段:
看规范 11.2.3 Function Calls:
这里讲了当函数调用的时候,如何确定 this 的取值。
只看第一步、第六步、第七步:
1.Let ref be the result of evaluating MemberExpression. 6.If Type(ref) is Reference, then a.If IsPropertyReference(ref) is true, then i.Let thisValue be GetBase(ref). b.Else, the base of ref is an Environment Record i.Let thisValue be the result of calling the ImplicitThisValue concrete method of GetBase(ref). 7.Else, Type(ref) is not Reference. a. Let thisValue be undefined.
让我们描述一下:
1.计算 MemberExpression 的结果赋值给 ref
2.判断 ref 是不是一个 Reference 类型
2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)
2.2 如果 ref 是 Reference,并且 base value 值是 Environment Record, 那么this的值为 ImplicitThisValue(ref)
2.3 如果 ref 不是 Reference,那么 this 的值为 undefined
具体分析
让我们一步一步看:
- 计算
MemberExpression
的结果赋值给 ref
什么是 MemberExpression
?看规范 11.2 Left-Hand-Side Expressions
:
MemberExpression
:
- PrimaryExpression // 原始表达式 可以参见《JavaScript 权威指南第四章》
- FunctionExpression // 函数定义表达式
- MemberExpression [ Expression ] // 属性访问表达式
- MemberExpression . IdentifierName // 属性访问表达式
- new MemberExpression Arguments // 对象创建表达式
举个例子:
function foo() { console.log(this)}
foo() // MemberExpression 是 foo
function foo() { return function () { console.log(this) }}
foo()() // MemberExpression 是 foo()
var foo = { bar: function () { return this },}
foo.bar() // MemberExpression 是 foo.bar
所以简单理解 MemberExpression
其实就是()
左边的部分。
2.判断 ref 是不是一个 Reference
类型。
关键就在于看规范是如何处理各种 MemberExpression
,返回的结果是不是一个 Reference
类型。
举最后一个例子:
var value = 1
var foo = { value: 2, bar: function () { return this.value },}
//示例1console.log(foo.bar())//示例2console.log(foo.bar())//示例3console.log((foo.bar = foo.bar)())//示例4console.log((false || foo.bar)())//示例5console.log((foo.bar, foo.bar)())
下面分别来解释上述代码中的示例
示例 1 foo.bar()
在示例 1 中,MemberExpression
计算的结果是 foo.bar,那么 foo.bar 是不是一个 Reference 呢?
查看规范 11.2.1 Property Accessors,这里展示了一个计算的过程,什么都不管了,就看最后一步:
Return a value of type Reference whose base value is baseValue and whose referenced name is propertyNameString, and whose strict mode flag is strict.
我们得知该表达式返回了一个 Reference
类型!
根据之前的内容,我们知道该值为:
var Reference = { base: foo, name: "bar", strict: false,}
接下来按照 2.1 的判断流程走:
2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)
该值是 Reference
类型,那么 IsPropertyReference(ref)
的结果是多少呢?
前面我们已经铺垫了 IsPropertyReference
方法,如果 base value
是一个对象,结果返回 true。
base value
为 foo,是一个对象,所以 IsPropertyReference(ref)
结果为 true。
这个时候我们就可以确定 this
的值了:
this = GetBase(ref),
GetBase
也已经铺垫了,获得 base value
值,这个例子中就是 foo,所以 this
的值就是 foo ,示例 1 的结果就是 2!
唉呀妈呀,为了证明 this
指向 foo,真是累死我了!但是知道了原理,剩下的就更快了。
示例 2 (foo.bar)()
看示例 2:
console.log(foo.bar())
foo.bar 被 () 包住,查看规范 11.1.6 The Grouping Operator
直接看结果部分:
Return the result of evaluating Expression. This may be of type Reference. NOTE This algorithm does not apply GetValue to the result of evaluating Expression.
实际上 () 并没有对 MemberExpression 进行计算,所以其实跟示例 1 的结果是一样的。
示例 3 (foo.bar = foo.bar)()
看示例 3,有赋值操作符,查看规范 11.13.1 Simple Assignment ( = ):
计算的第三步:
3.Let rval be GetValue(rref).
因为使用了 GetValue
,所以返回的值不是 Reference
类型,
按照之前讲的判断逻辑:
2.3 如果 ref 不是 Reference,那么 this 的值为 undefined
this
为 undefined,非严格模式下,this
的值为 undefined 的时候,其值会被隐式转换为全局对象。
示例 4 (false || foo.bar)()
看示例 4,逻辑与算法,查看规范 11.11 Binary Logical Operators:
计算第二步:
2.Let lval be GetValue(lref).
因为使用了 GetValue
,所以返回的不是 Reference
类型,this
为 undefined
示例 5 (foo.bar, foo.bar)()
看示例 5,逗号操作符,查看规范 11.14 Comma Operator ( , )
计算第二步:
2.Call GetValue(lref).
因为使用了 GetValue
,所以返回的不是 Reference
类型,this
为 undefined
揭晓结果
var value = 1
var foo = { value: 2, bar: function () { return this.value },}
//示例1console.log(foo.bar()) // 2//示例2console.log(foo.bar()) // 2//示例3console.log((foo.bar = foo.bar)()) // 1//示例4console.log((false || foo.bar)()) // 1//示例5console.log((foo.bar, foo.bar)()) // 1
注意:以上是在非严格模式下的结果,严格模式下因为 this 返回 undefined,所以示例 3 会报错。
补充
最最后,忘记了一个最最普通的情况:
function foo() { console.log(this)}
foo()
MemberExpression
是 foo,解析标识符,查看规范 10.3.1 Identifier Resolution,会返回一个 Reference
类型的值:
var fooReference = { base: EnvironmentRecord, name: "foo", strict: false,}
接下来进行判断:
2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)
因为 base value
是 EnvironmentRecord
,并不是一个 Object 类型,还记得前面讲过的 base value
的取值可能吗? 只可能是 undefined, an Object, a Boolean, a String, a Number, 和 an environment record 中的一种。
IsPropertyReference(ref)
的结果为 false,进入下个判断:
2.2 如果 ref 是 Reference,并且 base value 值是 Environment Record, 那么 this 的值为 ImplicitThisValue(ref)
base value
正是 Environment Record
,所以会调用 ImplicitThisValue(ref)
查看规范 10.2.1.1.6,ImplicitThisValue 方法的介绍:该函数始终返回 undefined。
所以最后 this
的值就是 undefined。
多说一句
尽管我们可以简单的理解 this
为调用函数的对象,如果是这样的话,如何解释下面这个例子呢?
var value = 1
var foo = { value: 2, bar: function () { return this.value },}console.log((false || foo.bar)()) // 1
此外,又如何确定调用函数的对象是谁呢?在写文章之初,我就面临着这些问题,最后还是放弃从多个情形下给大家讲解 this
指向的思路,而是追根溯源的从 ECMASciript 规范讲解 this
的指向,尽管从这个角度写起来和读起来都比较吃力,但是一旦多读几遍,明白原理,绝对会给你一个全新的视角看待 this
。而你也就能明白,尽管 foo() 和 (foo.bar = foo.bar)() 最后结果都指向了 undefined,但是两者从规范的角度上却有着本质的区别。
执行上下文
下面,让我们综合运用上文提到的知识点,来思考函数执行时的上下文的具体变化过程
以这个代码为例:
var scope = "global scope"function checkscope() { var scope = "local scope" function f() { return scope } return f()}checkscope()
执行过程如下:
1.执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈
ECStack = [globalContext]
2.全局上下文初始化
globalContext = { VO: [global], Scope: [globalContext.VO], this: globalContext.VO,}
2.初始化的同时,checkscope 函数被创建,保存作用域链到函数的内部属性 [[scope]]
checkscope[[scope]] = [globalContext.VO]
3.执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
ECStack = [checkscopeContext, globalContext]
4.checkscope 函数执行上下文初始化:
- 复制函数
[[scope]]
属性创建作用域链, - 用 arguments 创建活动对象,
- 初始化活动对象,即加入形参、函数声明、变量声明,
- 将活动对象压入 checkscope 作用域链顶端。
同时 f 函数被创建,保存作用域链到 f 函数的内部属性 [[scope]]
checkscopeContext = { AO: { arguments: { length: 0 }, scope: undefined, f: reference to function f(){} }, Scope: [AO, globalContext.VO], this: undefined }
5.执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈
ECStack = [fContext, checkscopeContext, globalContext]
6.f 函数执行上下文初始化, 以下跟第 4 步相同:
- 复制函数
[[scope]]
属性创建作用域链 - 用 arguments 创建活动对象
- 初始化活动对象,即加入形参、函数声明、变量声明
- 将活动对象压入 f 作用域链顶端
fContext = { AO: { arguments: { length: 0, }, }, Scope: [AO, checkscopeContext.AO, globalContext.VO], this: undefined,}
7.f 函数执行,沿着作用域链查找 scope
值,返回 scope
值
8.f 函数执行完毕,f 函数上下文从执行上下文栈中弹出
ECStack = [checkscopeContext, globalContext]
9.checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
ECStack = [globalContext]
闭包
MDN 对闭包的定义为:
闭包是指那些能够访问自由变量的函数。
那什么是自由变量呢?
自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。
由此,我们可以看出闭包共有两部分组成:
闭包 = 函数 + 函数能够访问的自由变量
举个例子:
var a = 1
function foo() { console.log(a)}
foo()
foo 函数可以访问变量 a,但是 a 既不是 foo 函数的局部变量,也不是 foo 函数的参数,所以 a 就是自由变量。
那么,函数 foo + foo 函数访问的自由变量 a 不就是构成了一个闭包嘛……
还真是这样的!
所以在《JavaScript 权威指南》中就讲到:从技术的角度讲,所有的 JavaScript 函数都是闭包。
咦,这怎么跟我们平时看到的讲到的闭包不一样呢!?
别着急,这是理论上的闭包,其实还有一个实践角度上的闭包,让我们看看汤姆大叔翻译的关于闭包的文章中的定义:
ECMAScript 中,闭包指的是:
- 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
- 从实践角度:以下函数才算是闭包:
- 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
- 在代码中引用了自由变量
接下来就来讲讲实践上的闭包。
分析
让我们先写个例子,例子依然是来自《JavaScript 权威指南》,稍微做点改动:
var scope = "global scope"function checkscope() { var scope = "local scope" function f() { return scope } return f}
var foo = checkscope()foo()
首先我们要分析一下这段代码中执行上下文栈和执行上下文的变化情况:
- 进入全局代码,创建全局执行上下文,全局执行上下文压入执行上下文栈
- 全局执行上下文初始化
- 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 执行上下文被压入执行上下文栈
- checkscope 执行上下文初始化,创建变量对象、作用域链、
this
等 - checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
- 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈
- f 执行上下文初始化,创建变量对象、作用域链、
this
等 - f 函数执行完毕,f 函数上下文从执行上下文栈中弹出
了解到这个过程,我们应该思考一个问题,那就是:
当 f 函数执行的时候,checkscope 函数上下文已经被销毁了啊(即从执行上下文栈中被弹出),怎么还会读取到 checkscope 作用域下的 scope 值呢?
以上的代码,要是转换成 PHP,就会报错,因为在 PHP 中,f 函数只能读取到自己作用域和全局作用域里的值,所以读不到 checkscope 下的 scope 值。
然而 JavaScript 却是可以的!
当我们了解了具体的执行过程后,我们知道 f 执行上下文维护了一个作用域链:
fContext = { Scope: [AO, checkscopeContext.AO, globalContext.VO],}
对的,就是因为这个作用域链,f 函数依然可以读取到 checkscopeContext.AO 的值,说明当 f 函数引用了 checkscopeContext.AO 中的值的时候,即使 checkscopeContext 被销毁了,但是 JavaScript 依然会让 checkscopeContext.AO 活在内存中,f 函数依然可以通过 f 函数的作用域链找到它,正是因为 JavaScript 做到了这一点,从而实现了闭包这个概念。
所以,让我们再看一遍实践角度上闭包的定义:
- 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
- 在代码中引用了自由变量
在这里再补充一个《JavaScript 权威指南》英文原版对闭包的定义:
This combination of a function object and a scope (a set of variable bindings) in which the function’s variables are resolved is called a closure in the computer science literature.
闭包在计算机科学中也只是一个普通的概念,大家不要去想得太复杂。
必刷题
接下来,看这道刷题必刷,面试必考的闭包题:
var data = []
for (var i = 0; i < 3; i++) { data[i] = function () { console.log(i) }}
data[0]()data[1]()data[2]()
答案是都是 3,让我们分析一下原因:
当执行到 data[0] 函数之前,此时全局上下文的 VO 为:
globalContext = { VO: { data: [...], i: 3 }}
当执行 data[0] 函数的时候,data[0] 函数的作用域链为:
data[0]Context = { Scope: [AO, globalContext.VO]}
data[0]Context 的 AO 并没有 i 值,所以会从 globalContext.VO 中查找,i 为 3,所以打印的结果就是 3。
data[1] 和 data[2] 是一样的道理。
所以让我们改成闭包看看:
var data = []
for (var i = 0; i < 3; i++) { data[i] = (function (i) { return function () { console.log(i) } })(i)}
data[0]()data[1]()data[2]()
当执行到 data[0] 函数之前,此时全局上下文的 VO 为:
globalContext = { VO: { data: [...], i: 3 }}
跟没改之前一模一样。
当执行 data[0] 函数的时候,data[0] 函数的作用域链发生了改变:
data[0]Context = { Scope: [AO, 匿名函数Context.AO globalContext.VO]}
匿名函数执行上下文的 AO 为:
匿名函数Context = { AO: { arguments: { 0: 0, length: 1, }, i: 0, },}
data[0]Context 的 AO 并没有 i 值,所以会沿着作用域链从匿名函数 Context.AO 中查找,这时候就会找 i 为 0,找到了就不会往 globalContext.VO 中查找了,即使 globalContext.VO 也有 i 的值(值为 3),所以打印的结果就是 0。
data[1] 和 data[2] 是一样的道理。
call、apply、bind
下面说一下和作用域相关的一个高频考点:call
、apply
和 bind
,以及手写实现
- call
一句话介绍 call:
call 就是指定一个
this
对象以及提供一系列参数来调用函数主体
例如:
var foo = { value: 1,}
function bar() { console.log(this.value)}
bar.call(foo) // 1
注意两点:
- call 改变了
this
的指向,指向到 foo - bar 函数执行了
模拟实现 call,要点如下:
- 将函数 bind 到指定
this
对象中,从而获得执行上下文,执行完成后再删除 - 为了模拟 ES3 中实现不定长传参,要结合
arguments
字符串数组和eval
来实现,否则用 ES6 的剩余操作符(...)即可 this
参数可以传null
,这时this
指向全局作用域(window 或 globalThis)
ES6 版本
Function.prototype.myCall = function (context, ...args) { if (typeof this !== "function") { throw new TypeError("only function can call myCall!") }
context = context || window || globalThis const fn = Symbol.for("fn") context[fn] = this
const res = context[fn](...args)
delete context[fn]
return res}
ES5 版本
Function.prototype.myCall = function (context) { var context = context || window || globalThis context.fn = this var args = [] for (let i = 1, len = arguments.length; i < len; i++) { args.push("arguments[" + i + "]") }
eval("context.fn(" + args + ")") delete context.fn}
var value = 1var foo = { value: 2,}
function bar(name, age) { console.log(name) console.log(age) console.log(this.value)}
bar.myCall(foo, "pfan", 28)// pfan// 28// 2bar.myCall(null, "pfan", 28)// pfan// 28// 1
- apply
ES6 版本
Function.prototype.myApply = function (context, arr) { if (typeof this !== "function") { throw new TypeError("only function can call apply!") }
context = context || window || globalThis const fn = Symbol("fn") context[fn] = this const res = context[fn](arr) delete context[fn] return res}
ES5 版本
apply
和 call
很像,唯一的区别就是,apply
只有 2 个参数,第二个参数是函数形参数组,而非 call
里面展开成多个参数
Function.prototype.apply = function (context, arr) { var context = Object(context) || window context.fn = this
var result if (!arr) { result = context.fn() } else { var args = [] for (var i = 0, len = arr.length; i < len; i++) { args.push("arr[" + i + "]") } result = eval("context.fn(" + args + ")") }
delete context.fn return result}
- bind
一句话介绍 bind:
bind() 方法会创建一个新函数。当这个新函数被调用时,bind() 的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数。(来自于 MDN )
由此我们可以首先得出 bind 函数的两个特点:
- 返回一个函数
- 可以传入参数
3*(没有显示提出,但是属于 bind 特性). 一个绑定函数也能使用 new 操作符创建对象:这种行为就像把原函数当成构造器。提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。
ES6 版本
Function.prototype.myBind = function (context, ...args) { if (typeof this !== "function") { throw new TypeError("only function can bind!") }
const self = this return function F() { if (this instanceof F) { return new self(...args, ...arguments) }
return self.apply(context, [...args, ...arguments]) }}
ES5 版本
Function.prototype.myBind = function (context) { var self = this // this 是函数调用者,不用 self 的话,当 this 是 window 的时候,会导致 this.apply 不生效 args = Array.prototype.slice.call(arguments, 1)
var fBound = function () { var callArgs = Array.prototype.slice.call(arguments) // 当作为构造函数时,this 指向实例,此时结果为 true,将绑定函数的 this 指向该实例,可以让实例获得来自绑定函数的值 // 以上面的是 demo 为例,如果改成 `this instanceof fBound ? null : context`,实例只是一个空对象,将 null 改成 this ,实例会具有 habit 属性 // 当作为普通函数时,this 指向 window,此时结果为 false,将绑定函数的 this 指向 context return this.apply( this instanceof fBound ? this : context, args.concat(callArgs) ) } // 修改返回函数的 prototype 为绑定函数的 prototype,实例就可以继承绑定函数的原型中的值 fBound.prototype = this.prototype return fBound}
但是在这个写法中,我们直接将 fBound.prototype = this.prototype,我们直接修改 fBound.prototype 的时候,也会直接修改绑定函数的 prototype。这个时候,我们可以通过一个空函数来进行中转:
Function.prototype.bind2 = function (context) { var self = this var args = Array.prototype.slice.call(arguments, 1)
var fNOP = function () {}
var fBound = function () { var bindArgs = Array.prototype.slice.call(arguments) return self.apply( this instanceof fNOP ? this : context, args.concat(bindArgs) ) }
fNOP.prototype = this.prototype fBound.prototype = new fNOP() return fBound}
接下来处理些小问题:
1.调用 bind 的不是函数咋办?
不行,我们要报错!
if (typeof this !== "function") { throw new Error( "Function.prototype.bind - what is trying to be bound is not callable" )}
2.我要在线上用
那别忘了做个兼容:
Function.prototype.bind = Function.prototype.bind || function () { ……};
当然最好是用 es5-shim 啦。
总结
对于 JS 函数闭包和作用域 this 指向,变量值等等基本是面试必考点之一了,之前听到一个关于 this
简单粗暴的记法是 谁调指谁
,这样是好判断,但是说实话一直对闭包和 this
作用域绕得有点懵逼,直到看完这这篇博客,通过函数执行上下文和作用域链的过程分析,能够帮助我们非常清晰地了解 JS 函数执行的整个环境状况,如此去理解闭包,分析闭包原理,以及 this
指向,作用域变量都变得很轻松了